import { type } from "arktype";
import { describe, expect, expectTypeOf, test, vi } from "vitest";
import { createEnv } from "../src/index.ts";
import { uploadthing } from "../src/presets-arktype.ts";

function ignoreErrors(cb: () => void) {
  try {
    cb();
  } catch {
    // ignore
  }
}

test("server vars should not be prefixed", () => {
  ignoreErrors(() => {
    createEnv({
      clientPrefix: "FOO_",
      server: {
        // @ts-expect-error - server should not have FOO_ prefix
        FOO_BAR: type("string"),
        BAR: type("string"),
      },
      client: {},
      runtimeEnv: {},
    });
  });
});

test("client vars should be correctly prefixed", () => {
  ignoreErrors(() => {
    createEnv({
      clientPrefix: "FOO_",
      server: {},
      client: {
        FOO_BAR: type("string"),
        // @ts-expect-error - no FOO_ prefix
        BAR: type("string"),
      },
      runtimeEnv: {},
    });
  });
});

test("runtimeEnvStrict enforces all keys", () => {
  createEnv({
    clientPrefix: "FOO_",
    server: {},
    client: {},
    runtimeEnvStrict: {},
  });

  createEnv({
    clientPrefix: "FOO_",
    server: {},
    client: { FOO_BAR: type("string") },
    runtimeEnvStrict: { FOO_BAR: "foo" },
  });

  createEnv({
    clientPrefix: "FOO_",
    server: { BAR: type("string") },
    client: {},
    runtimeEnvStrict: { BAR: "foo" },
  });

  createEnv({
    clientPrefix: "FOO_",
    server: { BAR: type("string") },
    client: { FOO_BAR: type("string") },
    runtimeEnvStrict: { BAR: "foo", FOO_BAR: "foo" },
  });

  createEnv({
    clientPrefix: "FOO_",
    server: {},
    client: { FOO_BAR: type("string") },
    runtimeEnvStrict: {
      FOO_BAR: "foo",
      // @ts-expect-error - FOO_BAZ is extraneous
      FOO_BAZ: "baz",
    },
  });

  ignoreErrors(() => {
    createEnv({
      clientPrefix: "FOO_",
      server: { BAR: type("string") },
      client: { FOO_BAR: type("string") },
      // @ts-expect-error - BAR is missing
      runtimeEnvStrict: {
        FOO_BAR: "foo",
      },
    });
  });
});

describe("return type is correctly inferred", () => {
  test("simple", () => {
    const env = createEnv({
      clientPrefix: "FOO_",
      server: { BAR: type("string") },
      client: { FOO_BAR: type("string") },
      runtimeEnvStrict: {
        BAR: "bar",
        FOO_BAR: "foo",
      },
    });

    expectTypeOf(env).toEqualTypeOf<
      Readonly<{
        BAR: string;
        FOO_BAR: string;
      }>
    >();

    expect(env).toMatchObject({
      BAR: "bar",
      FOO_BAR: "foo",
    });
  });

  test("with transforms", () => {
    const env = createEnv({
      clientPrefix: "FOO_",
      server: { BAR: type("string").pipe(Number) },
      client: { FOO_BAR: type("string") },
      runtimeEnvStrict: {
        BAR: "123",
        FOO_BAR: "foo",
      },
    });

    expectTypeOf(env).toEqualTypeOf<
      Readonly<{
        BAR: number;
        FOO_BAR: string;
      }>
    >();

    expect(env).toMatchObject({
      BAR: 123,
      FOO_BAR: "foo",
    });
  });

  test("without client vars", () => {
    const env = createEnv({
      clientPrefix: "FOO_",
      server: { BAR: type("string") },
      client: {},
      runtimeEnvStrict: {
        BAR: "bar",
      },
    });

    expectTypeOf(env).toEqualTypeOf<
      Readonly<{
        BAR: string;
      }>
    >();

    expect(env).toMatchObject({
      BAR: "bar",
    });
  });
});

test("can pass number and booleans", () => {
  const env = createEnv({
    clientPrefix: "FOO_",
    server: {
      PORT: type("number"),
      IS_DEV: type("boolean"),
    },
    client: {},
    runtimeEnvStrict: {
      PORT: 123,
      IS_DEV: true,
    },
  });

  expectTypeOf(env).toEqualTypeOf<
    Readonly<{
      PORT: number;
      IS_DEV: boolean;
    }>
  >();

  expect(env).toMatchObject({
    PORT: 123,
    IS_DEV: true,
  });
});

describe("errors when validation fails", () => {
  test("envs are missing", () => {
    expect(() =>
      createEnv({
        clientPrefix: "FOO_",
        server: { BAR: type("string") },
        client: { FOO_BAR: type("string") },
        runtimeEnv: {},
      }),
    ).toThrow("Invalid environment variables");
  });

  test("envs are invalid", () => {
    expect(() =>
      createEnv({
        clientPrefix: "FOO_",
        server: { BAR: type("string").pipe(Number, type("number")) },
        client: { FOO_BAR: type("string") },
        runtimeEnv: {
          BAR: "123abc",
          FOO_BAR: "foo",
        },
      }),
    ).toThrow("Invalid environment variables");
  });

  test("with custom error handler", () => {
    expect(() =>
      createEnv({
        clientPrefix: "FOO_",
        server: { BAR: type("string").pipe(Number, type("number")) },
        client: { FOO_BAR: type("string") },
        runtimeEnv: {
          BAR: "123abc",
          FOO_BAR: "foo",
        },
        onValidationError: (issues) => {
          const barError = issues.find((issue) => issue.path?.[0] === "BAR")?.message;
          throw new Error(`Invalid variable BAR: ${barError}`);
        },
      }),
    ).toThrow("Invalid variable BAR: must be a number (was NaN)");
  });
});

describe("errors when server var is accessed on client", () => {
  test("with default handler", () => {
    const env = createEnv({
      clientPrefix: "FOO_",
      server: { BAR: type("string") },
      client: { FOO_BAR: type("string") },
      runtimeEnvStrict: {
        BAR: "bar",
        FOO_BAR: "foo",
      },
      isServer: false,
    });

    expect(() => env.BAR).toThrow(
      "❌ Attempted to access a server-side environment variable on the client",
    );
  });

  test("with custom handler", () => {
    const env = createEnv({
      clientPrefix: "FOO_",
      server: { BAR: type("string") },
      client: { FOO_BAR: type("string") },
      runtimeEnvStrict: {
        BAR: "bar",
        FOO_BAR: "foo",
      },
      isServer: false,
      onInvalidAccess: (key) => {
        throw new Error(`Attempted to access ${key} on the client`);
      },
    });

    expect(() => env.BAR).toThrow("Attempted to access BAR on the client");
  });
});

describe("client/server only mode", () => {
  test("client only", () => {
    const env = createEnv({
      clientPrefix: "FOO_",
      client: {
        FOO_BAR: type("string"),
      },
      runtimeEnv: { FOO_BAR: "foo" },
    });

    expectTypeOf(env).toEqualTypeOf<Readonly<{ FOO_BAR: string }>>();
    expect(env).toMatchObject({ FOO_BAR: "foo" });
  });

  test("server only", () => {
    const env = createEnv({
      server: {
        BAR: type("string"),
      },
      runtimeEnv: { BAR: "bar" },
    });

    expectTypeOf(env).toEqualTypeOf<Readonly<{ BAR: string }>>();
    expect(env).toMatchObject({ BAR: "bar" });
  });

  test("config with missing client", () => {
    ignoreErrors(() => {
      createEnv(
        // @ts-expect-error - incomplete client config - client not present
        {
          clientPrefix: "FOO_",
          server: {},
          runtimeEnv: {},
        },
      );
    });
  });

  test("config with missing clientPrefix", () => {
    ignoreErrors(() => {
      // @ts-expect-error - incomplete client config - clientPrefix not present
      createEnv({
        client: {},
        server: {},
        runtimeEnv: {},
      });
    });
  });
});

describe("shared can be accessed on both server and client", () => {
  process.env = {
    NODE_ENV: "development",
    BAR: "bar",
    FOO_BAR: "foo",
  };

  function lazyCreateEnv() {
    return createEnv({
      shared: {
        NODE_ENV: type("'development' | 'production' | 'test'"),
      },
      clientPrefix: "FOO_",
      server: { BAR: type("string") },
      client: { FOO_BAR: type("string") },
      runtimeEnv: process.env,
    });
  }

  expectTypeOf(lazyCreateEnv).returns.toEqualTypeOf<
    Readonly<{
      NODE_ENV: "development" | "production" | "test";
      BAR: string;
      FOO_BAR: string;
    }>
  >();

  test("server", () => {
    const { window } = globalThis;

    globalThis.window = undefined as any;

    const env = lazyCreateEnv();

    expect(env).toMatchObject({
      NODE_ENV: "development",
      BAR: "bar",
      FOO_BAR: "foo",
    });

    globalThis.window = window;
  });

  test("client", () => {
    const { window } = globalThis;

    globalThis.window = {} as any;

    const env = lazyCreateEnv();

    expect(() => env.BAR).toThrow(
      "❌ Attempted to access a server-side environment variable on the client",
    );
    expect(env.FOO_BAR).toBe("foo");
    expect(env.NODE_ENV).toBe("development");

    globalThis.window = window;
  });
});

test("envs are readonly", () => {
  const env = createEnv({
    server: { BAR: type("string") },
    runtimeEnv: { BAR: "bar" },
  });

  /**
   * We currently don't enforce readonly during runtime:
   * https://github.com/t3-oss/t3-env/pull/111#issuecomment-1682931526
   */

  // expect(() => {
  //   // @ts-expect-error - envs are readonly
  //   env.BAR = "foo";
  // }).toThrow(
  //   '"Cannot assign to read only property BAR of object #<Object>"'
  // );

  // expect(env).toMatchObject({ BAR: "bar" });

  // @ts-expect-error - envs are readonly
  env.BAR = "foo";
  expect(env).toMatchObject({ BAR: "foo" });
});

describe("extending presets", () => {
  test("with invalid runtime envs", () => {
    const processEnv = {
      SERVER_ENV: "server",
      CLIENT_ENV: "client",
    };

    function lazyCreateEnv() {
      const preset = createEnv({
        server: {
          PRESET_ENV: type("string"),
        },
        runtimeEnv: processEnv,
      });

      return createEnv({
        server: {
          SERVER_ENV: type("string"),
        },
        clientPrefix: "CLIENT_",
        client: {
          CLIENT_ENV: type("string"),
        },
        extends: [preset],
        runtimeEnv: processEnv,
      });
    }

    expectTypeOf(lazyCreateEnv).returns.toEqualTypeOf<
      Readonly<{
        SERVER_ENV: string;
        CLIENT_ENV: string;
        PRESET_ENV: string;
      }>
    >();

    const consoleError = vi.spyOn(console, "error");
    expect(() => lazyCreateEnv()).toThrow("Invalid environment variables");
    expect(consoleError.mock.calls[0]).toEqual([
      "❌ Invalid environment variables:",
      [
        expect.objectContaining({
          message: expect.any(String),
          path: ["PRESET_ENV"],
        }),
      ],
    ]);
  });
  describe("single preset", () => {
    const processEnv = {
      PRESET_ENV: "preset",
      SHARED_ENV: "shared",
      SERVER_ENV: "server",
      CLIENT_ENV: "client",
    };

    function lazyCreateEnv() {
      const preset = createEnv({
        server: {
          PRESET_ENV: type("'preset'"),
        },
        runtimeEnv: processEnv,
      });

      return createEnv({
        server: {
          SERVER_ENV: type("string"),
        },
        shared: {
          SHARED_ENV: type("string"),
        },
        clientPrefix: "CLIENT_",
        client: {
          CLIENT_ENV: type("string"),
        },
        extends: [preset],
        runtimeEnv: processEnv,
      });
    }

    expectTypeOf(lazyCreateEnv).returns.toEqualTypeOf<
      Readonly<{
        SERVER_ENV: string;
        SHARED_ENV: string;
        CLIENT_ENV: string;
        PRESET_ENV: "preset";
      }>
    >();

    test("server", () => {
      const { window } = globalThis;
      globalThis.window = undefined as any;

      const env = lazyCreateEnv();

      expect(env).toMatchObject({
        SERVER_ENV: "server",
        SHARED_ENV: "shared",
        CLIENT_ENV: "client",
        PRESET_ENV: "preset",
      });

      globalThis.window = window;
    });

    test("client", () => {
      const { window } = globalThis;
      globalThis.window = {} as any;

      const env = lazyCreateEnv();

      expect(() => env.SERVER_ENV).toThrow(
        "❌ Attempted to access a server-side environment variable on the client",
      );
      expect(() => env.PRESET_ENV).toThrow(
        "❌ Attempted to access a server-side environment variable on the client",
      );
      expect(env.SHARED_ENV).toBe("shared");
      expect(env.CLIENT_ENV).toBe("client");

      globalThis.window = window;
    });
  });

  describe("multiple presets", () => {
    const processEnv = {
      PRESET_ENV1: "preset",
      PRESET_ENV2: 123,
      SHARED_ENV: "shared",
      SERVER_ENV: "server",
      CLIENT_ENV: "client",
    };

    function lazyCreateEnv() {
      const preset1 = createEnv({
        server: {
          PRESET_ENV1: type("'preset'"),
        },
        runtimeEnv: processEnv,
      });

      const preset2 = createEnv({
        server: {
          PRESET_ENV2: type("number"),
        },
        runtimeEnv: processEnv,
      });

      return createEnv({
        server: {
          SERVER_ENV: type("string"),
        },
        shared: {
          SHARED_ENV: type("string"),
        },
        clientPrefix: "CLIENT_",
        client: {
          CLIENT_ENV: type("string"),
        },
        extends: [preset1, preset2],
        runtimeEnv: processEnv,
      });
    }

    expectTypeOf(lazyCreateEnv).returns.toEqualTypeOf<
      Readonly<{
        PRESET_ENV1: "preset";
        PRESET_ENV2: number;
        SERVER_ENV: string;
        SHARED_ENV: string;
        CLIENT_ENV: string;
      }>
    >();

    test("server", () => {
      const { window } = globalThis;
      globalThis.window = undefined as any;

      const env = lazyCreateEnv();

      expect(env).toMatchObject({
        PRESET_ENV1: "preset",
        PRESET_ENV2: 123,
        SERVER_ENV: "server",
        SHARED_ENV: "shared",
        CLIENT_ENV: "client",
      });

      globalThis.window = window;
    });

    test("client", () => {
      const { window } = globalThis;
      globalThis.window = {} as any;

      const env = lazyCreateEnv();

      expect(() => env.SERVER_ENV).toThrow(
        "❌ Attempted to access a server-side environment variable on the client",
      );
      expect(() => env.PRESET_ENV1).toThrow(
        "❌ Attempted to access a server-side environment variable on the client",
      );
      expect(() => env.PRESET_ENV2).toThrow(
        "❌ Attempted to access a server-side environment variable on the client",
      );
      expect(env.SHARED_ENV).toBe("shared");
      expect(env.CLIENT_ENV).toBe("client");

      globalThis.window = window;
    });
  });
});

describe("createFinalSchema", () => {
  test("custom schema combiner", () => {
    let receivedIsServer = false;
    const env = createEnv({
      server: {
        SERVER_ENV: type("string"),
      },
      shared: {
        SHARED_ENV: type("string"),
      },
      clientPrefix: "CLIENT_",
      client: {
        CLIENT_ENV: type("string"),
      },
      runtimeEnv: {
        SERVER_ENV: "server",
        SHARED_ENV: "shared",
        CLIENT_ENV: "client",
      },
      createFinalSchema: (shape, isServer) => {
        expectTypeOf(isServer).toEqualTypeOf<boolean>();
        if (typeof isServer === "boolean") receivedIsServer = true;
        return type(shape);
      },
    });
    expectTypeOf(env).toEqualTypeOf<
      Readonly<{
        SERVER_ENV: string;
        SHARED_ENV: string;
        CLIENT_ENV: string;
      }>
    >();
    expect(env).toMatchObject({
      SERVER_ENV: "server",
      SHARED_ENV: "shared",
      CLIENT_ENV: "client",
    });
    expect(receivedIsServer).toBe(true);
  });
  // test("schema combiner with further refinement", () => {
  //   const env = createEnv({
  //     server: {
  //       SKIP_AUTH: type("boolean|undefined"),
  //       EMAIL: type("string.email | undefined"),
  //       PASSWORD: type("string>1 | undefined"),
  //     },
  //     runtimeEnv: {
  //       SKIP_AUTH: true,
  //     },
  //     createFinalSchema: (shape) =>
  //       type(shape).pipe(
  //         (env) => env.SKIP_AUTH || !!(env.EMAIL && env.PASSWORD),
  //         type(shape)
  //       ),
  //   });
  //   expectTypeOf(env).toEqualTypeOf<
  //     Readonly<{
  //       SKIP_AUTH?: boolean;
  //       EMAIL?: string;
  //       PASSWORD?: string;
  //     }>
  //   >();
  //   expect(env).toMatchObject({ SKIP_AUTH: true });
  // });
  // test("schema combiner that changes the type", () => {
  //   const env = createEnv({
  //     server: {
  //       SKIP_AUTH: type("boolean|undefined"),
  //       EMAIL: type("string.email | undefined"),
  //       PASSWORD: type("string>1 | undefined"),
  //     },
  //     runtimeEnv: {
  //       SKIP_AUTH: true,
  //     },
  //     createFinalSchema: (shape) =>
  //       v.pipe(
  //         v.object(shape),
  //         v.rawTransform(({ addIssue, dataset, NEVER }) => {
  //           const env = dataset.value;
  //           if (env.SKIP_AUTH) return { SKIP_AUTH: true } as const;
  //           if (!env.EMAIL || !env.PASSWORD) {
  //             addIssue({
  //               message:
  //                 "EMAIL and PASSWORD are required if SKIP_AUTH is false",
  //             });
  //             return NEVER;
  //           }
  //           return {
  //             EMAIL: env.EMAIL,
  //             PASSWORD: env.PASSWORD,
  //           };
  //         }),
  //       ),
  //   });
  //   expectTypeOf(env).toEqualTypeOf<
  //     Readonly<
  //       | {
  //           readonly SKIP_AUTH: true;
  //           EMAIL?: undefined;
  //           PASSWORD?: undefined;
  //         }
  //       | {
  //           readonly SKIP_AUTH?: undefined;
  //           EMAIL: string;
  //           PASSWORD: string;
  //         }
  //     >
  //   >();
  //   expect(env).toMatchObject({ SKIP_AUTH: true });
  // });
});
test("empty 'extends' array should not cause type errors", () => {
  const env = createEnv({
    clientPrefix: "FOO_",
    server: { BAR: type("string") },
    client: { FOO_BAR: type("string") },
    runtimeEnvStrict: {
      BAR: "bar",
      FOO_BAR: "foo",
    },
    extends: [],
  });

  expectTypeOf(env).toEqualTypeOf<
    Readonly<{
      BAR: string;
      FOO_BAR: string;
    }>
  >();

  expect(env).toMatchObject({
    BAR: "bar",
    FOO_BAR: "foo",
  });
});

test("overriding preset env var", () => {
  const preset = createEnv({
    server: {
      PRESET_ENV: type("string"),
    },
    runtimeEnv: { PRESET_ENV: "preset" },
  });

  const env = createEnv({
    server: {
      PRESET_ENV: type("string | number").pipe(Number),
    },
    extends: [preset],
    runtimeEnv: { PRESET_ENV: 123 },
  });

  expectTypeOf(env).toEqualTypeOf<
    Readonly<{
      PRESET_ENV: number;
    }>
  >();
  expect(env.PRESET_ENV).toBe(123);
});

test("with built-in preset", () => {
  process.env.UPLOADTHING_TOKEN = "token";
  const env = createEnv({
    server: {
      FOO: type("string"),
    },
    extends: [uploadthing()],
    runtimeEnv: { FOO: "bar" },
  });

  expectTypeOf(env).toEqualTypeOf<
    Readonly<{
      FOO: string;
      UPLOADTHING_TOKEN: string;
    }>
  >();

  expect(env.FOO).toBe("bar");
  expect(env.UPLOADTHING_TOKEN).toBe("token");
});
